package il.technion.cs236369.proxy;

import java.io.BufferedReader;
import java.io.IOException;
import java.io.InputStream;
import java.io.InputStreamReader;
import java.io.OutputStream;
import java.io.OutputStreamWriter;
import java.io.Reader;
import java.io.StringWriter;
import java.io.Writer;
import java.net.MalformedURLException;
import java.net.Socket;
import java.net.URI;
import java.net.URISyntaxException;
import java.net.URL;
import java.net.UnknownHostException;

import javax.net.SocketFactory;

import org.apache.http.Header;
import org.apache.http.HeaderIterator;
import org.apache.http.HttpEntity;
import org.apache.http.HttpException;
import org.apache.http.HttpHeaders;
import org.apache.http.HttpRequest;
import org.apache.http.HttpResponse;
import org.apache.http.HttpStatus;
import org.apache.http.HttpVersion;
import org.apache.http.entity.BufferedHttpEntity;
import org.apache.http.entity.ContentProducer;
import org.apache.http.entity.EntityTemplate;
import org.apache.http.impl.DefaultHttpClientConnection;
import org.apache.http.message.BasicHttpRequest;
import org.apache.http.message.BasicHttpResponse;
import org.apache.http.message.BasicStatusLine;
import org.apache.http.params.BasicHttpParams;
import org.apache.http.params.HttpParams;
import org.apache.http.params.HttpProtocolParams;
import org.apache.http.protocol.BasicHttpContext;
import org.apache.http.protocol.HttpContext;
import org.apache.http.protocol.HttpRequestExecutor;
import org.apache.http.protocol.HttpRequestHandler;
import org.apache.log4j.Logger;

/**
 * <p>
 * Handles the client request and returns response
 * </p>
 * 
 * @author Bikman Michael, 317920999, smbikman@t2
 * @author Yantsen Maria, 310182316, syantsen@t2
 */
public class HttpProxyRequestHandler implements HttpRequestHandler {

	private SocketFactory sockFactory;
	private Socket socketToServer;
	private DefaultHttpClientConnection connToServer;
	private final long MAX_RESPONSE_LENGTH = 65535;
	private Logger sysLogger = Logger.getLogger(LogUtils.system);

	/**
	 * Instantiates a new http proxy request handler.
	 * 
	 * @param sockFactory
	 *            the sock factory
	 */
	public HttpProxyRequestHandler(SocketFactory sockFactory) {
		this.sockFactory = sockFactory;
	}

	/**
	 * Handles the request
	 * 
	 * @see org.apache.http.protocol.HttpRequestHandler#handle(org.apache.http.HttpRequest,
	 * org.apache.http.HttpResponse, org.apache.http.protocol.HttpContext)
	 */
	@Override
	public void handle(final HttpRequest request, final HttpResponse response, final HttpContext context) {

		try {
			// remove header that allows ZIPped content
			removeZippedAcception(request);

			// DEBUG CODE
			printRequestHeaders(request);

			// check that request is GET and return error if not
			if (!isGetRequest(request)) {
				generateErrorResponse(response, HttpStatus.SC_BAD_REQUEST, "Only GET requests are allowed");
				return;
			}

			// check if request has no 'Cache-Control: no-cache'
			if (useCache(request)) {
				sysLogger.info("$Fetching response from cache...\n");
				HttpResponse cachedReply = Cache.get(request);
				// HttpResponse cachedReply = null;

				// DEBUG code
				if (cachedReply != null)
					printResponseHeaders(cachedReply);

				if (cachedReply != null && isReplyValid(request, cachedReply)) {
					// sysLogger.info("$Response found in cache...\n");

					// copy data from one reply to another
					fillResponse(cachedReply, response);
					sysLogger.info("$Response returned from cache...\n");
					return;
				} else {
					sysLogger.info("$No response found in cache...\n");
				}
			}

			// --> Here we know that no cache is used for get reply
			sysLogger.info("@Fetching response from server...\n");

			//
			// Get reply from remote server
			//
			executeProxyToServerRequest(request, response, context);

			printResponseHeaders(response);

			// cache if satisfies conditions
			if (useCache(response)) {
				sysLogger.info("$Storing response to cache...\n");
				Cache.store(request, response);
			}

		} catch (IOException e) {
			Logger.getLogger(LogUtils.system).error(e.getMessage() + "\n", e);
			System.err.println("RequestHandler: error during IO operation");
		}

	}

	/**
	 * Checks if the response is valid according to the time stamps
	 * 
	 * @param request
	 *            the request
	 * @param response
	 *            the response
	 * @return true, if is reply valid
	 */
	private boolean isReplyValid(final HttpRequest request, final HttpResponse response) {
		try {
			BasicHttpRequest validateRequest = new BasicHttpRequest("GET", request.getRequestLine().getUri(),
					HttpVersion.HTTP_1_1);
			String lastModifiedValue = null;

			// search for the value of the header Last-Modified
			for (Header h : response.getHeaders(HttpHeaders.LAST_MODIFIED)) {
				lastModifiedValue = h.getValue();
			}

			if (lastModifiedValue == null) {
				Logger.getLogger(LogUtils.system).error("No Last-Modified header in response\n");
				return false;
			}

			URI uri = new URI(request.getRequestLine().getUri());

			validateRequest.addHeader(HttpHeaders.HOST, uri.getHost());
			validateRequest.addHeader(HttpHeaders.IF_MODIFIED_SINCE, lastModifiedValue);
			validateRequest.addHeader(HttpHeaders.CONNECTION, "close");

			// sending request to server
			Logger.getLogger(LogUtils.system).info("Validate: Request sent to server\n");

			// DEBUG code
			printRequestHeaders(validateRequest);

			BasicHttpResponse validationResponse = new BasicHttpResponse(new BasicStatusLine(HttpVersion.HTTP_1_1,
					HttpStatus.SC_OK, "OK"));

			HttpContext tmpContext = new BasicHttpContext();

			executeProxyToServerRequest(validateRequest, validationResponse, tmpContext);

			// we close the validation connection to server here
			socketToServer.close();
			
			// FIX: HttpProxyTestBasicValidation
			// maybe we should close the connToServer (its in the tmpContext, returned from executeProxyToServerRequest) ???

			Logger.getLogger(LogUtils.system).info(
					"Validate connection to server is closed. Response returned from server:\n");

			// DEBUG code
			printResponseHeaders(validationResponse);

			// checking the result of the validating request
			switch (validationResponse.getStatusLine().getStatusCode()) {
			case HttpStatus.SC_OK:
				fillResponse(validationResponse, response);
				Cache.store(request, validationResponse);
				Logger.getLogger(LogUtils.system).info("Validate: failure, replacing the entry in cache\n");
				// validation failed, but still we return true
				// because we have the valid entry and we store it in cache
				return true;
			case HttpStatus.SC_NOT_MODIFIED:
				// DEBUG code
				// System.out.println("Validate: success");
				Logger.getLogger(LogUtils.system).info("Validate: success\n");
				return true;
			case HttpStatus.SC_NOT_FOUND:
				Logger.getLogger(LogUtils.system).info("Validate: source not found\n");
				Cache.deleteEntry(new URI(request.getRequestLine().getUri()));
				return false;
			default:
				Logger.getLogger(LogUtils.system).info("Validate: default failure\n");
				return false;
			}

		} catch (URISyntaxException e) {
			Logger.getLogger(LogUtils.system).error(e.getMessage() + "\n", e);
			System.err.println("RequestHandler: Uri parsing failure");
		}

		catch (IOException e) {
			Logger.getLogger(LogUtils.system).error(e.getMessage() + "\n", e);
			System.err.println("RequestHandler: error during IO operation");
		}
		return false;

	}

	/**
	 * Removes the acception of zipped content
	 * 
	 * @param request
	 *            the request
	 */
	private void removeZippedAcception(final HttpRequest request) {
		if (request.containsHeader(HttpHeaders.ACCEPT_ENCODING)) {
			sysLogger.info("@Request " + HttpHeaders.ACCEPT_ENCODING + " header removed.\n");
			request.removeHeaders(HttpHeaders.ACCEPT_ENCODING);
		}
	}

	/**
	 * Checks if we need to fetch reply from the cache
	 * 
	 * @param request
	 *            the request to be checked
	 * @return true, if successful
	 */
	private boolean useCache(HttpRequest request) {
		boolean needToUseCache = true;

		// Check if request has "Cache-Control: no-cache" OR
		// "Cache-Control: no-store" headers
		for (Header h : request.getHeaders(HttpHeaders.CACHE_CONTROL)) {
			if (h.getValue().trim().equalsIgnoreCase("no-cache") || h.getValue().equalsIgnoreCase("no-store")) {
				needToUseCache = false;
			}
		}

		return needToUseCache;
	}

	/**
	 * Check that reply satisfies caching conditions and can be stored in cache
	 * 
	 * @param response
	 *            the response to be checked
	 * @return true, if successfully checked
	 */
	private boolean useCache(final HttpResponse response) {

		// check that status is 200 OK
		if (response.getStatusLine().getStatusCode() != HttpStatus.SC_OK) {
			return false;
		}

		// check reply hasn't 'Cache-Control: no-store / no-cache' headers
		for (Header h : response.getHeaders(HttpHeaders.CACHE_CONTROL)) {
			if (h.getValue().equalsIgnoreCase("no-cache") || h.getValue().equalsIgnoreCase("no-store")) {
				Logger.getLogger(LogUtils.cache).info("Reply has 'Cache-Control: no-store / no-cache' headers");
				return false;
			}
		}

		// check that there is LAST_MODIFIED header
		if (response.getHeaders(HttpHeaders.LAST_MODIFIED).length == 0)
			return false;

		// check if response is chunked
		for (Header h : response.getHeaders(HttpHeaders.TRANSFER_ENCODING)) {
			if (h.getValue().equalsIgnoreCase("chunked")) {
				Logger.getLogger(LogUtils.cache).info("Reply is chunked\n");
				return false;
			}
		}

		// check if the response is not too big
		if ((response.getEntity() != null) && (response.getEntity().getContentLength() > MAX_RESPONSE_LENGTH)) {
			Logger.getLogger(LogUtils.cache).info("Reply is too big");
			return false;
		}
		return true;
	}

	/**
	 * Copies data from one response to another
	 * 
	 * @param from
	 *            source response
	 * @param to
	 *            destination response
	 * @throws IOException
	 *             Signals that an I/O exception has occurred.
	 */
	private void fillResponse(final HttpResponse from, final HttpResponse to) throws IOException {

		// copy status line
		to.setStatusLine(from.getStatusLine());

		// copy headers
		for (Header h : from.getAllHeaders()) {
			if (!to.containsHeader(h.getName())) {
				to.addHeader(h);
			}
		}

		if (from.getEntity() != null) {
			// create repeatable entity
			BufferedHttpEntity entity = new BufferedHttpEntity(from.getEntity());
			from.setEntity(entity);

			// copy entity
			to.setEntity(entity);
		}

	}

	/**
	 * Creates error response that is need to be send to user
	 * 
	 * @param response
	 *            generated response to be send
	 * @param errorCode
	 *            the error code
	 * @param errorMsg
	 *            the error message
	 */
	private void generateErrorResponse(final HttpResponse response, int errorCode, String errorMsg) {
		response.setStatusCode(errorCode);
		response.setReasonPhrase(errorMsg);
	}

	/**
	 * Checks if request method is GET
	 * 
	 * @param request
	 *            the request to check
	 * @return true, if is gets the GET method request
	 */
	private boolean isGetRequest(final HttpRequest request) {
		// check request method (must be GET only)
		if (request.getRequestLine().getMethod().equalsIgnoreCase("GET"))
			return true;
		else
			return false;
	}

	/**
	 * Connects to remote server and get a reply from it
	 * 
	 * @param proxyToServerRequest
	 *            request to server from proxy
	 * @param response
	 *            the response from server to proxy
	 * @param context
	 *            the common context
	 */
	private void executeProxyToServerRequest(final HttpRequest proxyToServerRequest, final HttpResponse response,
			final HttpContext context) {

		try {
			//
			// Get URL from request to server
			//
			URL url = new URL(proxyToServerRequest.getRequestLine().getUri());

			// Check if the port number specified
			int servPort = url.getPort();
			if (servPort == -1)
				servPort = 80;

			//
			// Remove absolute path
			//
			String[] uriTokensArray = proxyToServerRequest.getRequestLine().getUri().split("/", 4);

			String relativePath; // = uriTokensArray.length < 3 ? "/" : "/" + uriTokensArray[3];
			if (uriTokensArray.length < 3) {
				relativePath = "/"; // root
			} else {
				relativePath = "/" + uriTokensArray[3]; // root + path
			}

			// create new request with relative path
			HttpRequest newToServReq = new BasicHttpRequest("GET", relativePath);

			// copy headers to new request
			for (Header h : proxyToServerRequest.getAllHeaders()) {
				newToServReq.addHeader(h);
			}

			// DEBUG code
			// printRequestHeaders(newToServReq);

			//
			// Initialize socket and connection
			//
			socketToServer = sockFactory.createSocket(url.getHost(), servPort);
			connToServer = new DefaultHttpClientConnection();

			// prepare parameters
			HttpParams params = new BasicHttpParams();
			HttpProtocolParams.setVersion(params, HttpVersion.HTTP_1_1);

			//
			// Open connection to remote server
			//
			if (!connToServer.isOpen()) {
				connToServer.bind(socketToServer, params);
			}

			HttpRequestExecutor httpexecutor = new HttpRequestExecutor();

			// ask with new request
			HttpResponse servResponse = httpexecutor.execute(newToServReq, connToServer, context);

			context.setAttribute("connToServer", connToServer);
			context.setAttribute("socketToServer", socketToServer);

			//
			// Copy data from server reply to client reply
			//
			fillResponse(servResponse, response);

			// DEBUG code
			// printResponseHeaders(response);

		} catch (UnknownHostException e) {
			Logger.getLogger(LogUtils.system).error(e.getMessage() + "\n", e);
			System.err.println("RequestHandler: Connection to server failed");
			generateErrorResponse(response, HttpStatus.SC_INTERNAL_SERVER_ERROR, "Connection failure");
		} catch (MalformedURLException e) {
			Logger.getLogger(LogUtils.system).error(e.getMessage() + "\n", e);
			System.err.println("RequestHandler: Url parsing failure");
		} catch (IOException e) {
			Logger.getLogger(LogUtils.system).error(e.getMessage() + "\n", e);
			
			// FIX: HttpProxyTestServerNotFound
			generateErrorResponse(response, HttpStatus.SC_INTERNAL_SERVER_ERROR, "Unable to get reply");
			
			System.err.println("RequestHandler: error during IO operation");
		} catch (HttpException e) {
			Logger.getLogger(LogUtils.system).error(e.getMessage() + "\n", e);
			generateErrorResponse(response, HttpStatus.SC_INTERNAL_SERVER_ERROR, "Unable to get reply");
			System.err.println("RequestHandler: Unable to get reply from server");
		} finally {
			// connection is closed OUTSIDE the function
		}

	}

	/**
	 * Prints the response headers
	 * 
	 * @param response
	 *            the response to be printed
	 */
	private void printResponseHeaders(HttpResponse response) {
		String res = "\n---------------RESPONSE---------------\n";
		res += response.getStatusLine().toString();
		res += "\n";
		HeaderIterator it = response.headerIterator();
		while (it.hasNext()) {
			res += it.next();
			res += "\n";
		}
		res += "--------------------------------------\n";
		Logger.getLogger(LogUtils.server).info(res);
	}

	/**
	 * Prints the request headers
	 * 
	 * @param request
	 *            the request to be printed
	 */
	private void printRequestHeaders(final HttpRequest request) {
		// Print the incoming request
		String res = "\n---------------REQUEST----------------\n";
		res += request.getRequestLine().toString();
		res += "\n";
		HeaderIterator it = request.headerIterator();
		while (it.hasNext()) {
			res += it.next();
			res += "\n";
		}
		res += "--------------------------------------\n";
		Logger.getLogger(LogUtils.server).info(res);
	}

	/**
	 * Creates the simple response (used for debug)
	 * 
	 * @param request
	 *            the request
	 * @param response
	 *            the response
	 */
	@SuppressWarnings("unused")
	private void createSimpleResponse(final HttpRequest request, final HttpResponse response) {
		response.setStatusCode(HttpStatus.SC_OK);
		EntityTemplate body = new EntityTemplate(new ContentProducer() {

			@Override
			public void writeTo(final OutputStream out) throws IOException {
				OutputStreamWriter writer = new OutputStreamWriter(out, "UTF-8");
				writer.write("<html>");
				writer.write("<head><style type=\"text/css\">body{font-family:\"Courier New\";}</style></head>");
				writer.write("<body>");
				writer.write("<h2>Got request </h2>");
				writer.write(request.getRequestLine().toString() + "<br/>");
				HeaderIterator it = request.headerIterator();
				while (it.hasNext()) {
					writer.write(it.next().toString() + "<br/>");
				}
				writer.write("</body></html>");
				writer.flush();
			}
		});

		body.setContentType("text/html; charset=UTF-8");
		response.setEntity(body);
	}

	/**
	 * Prints the response entity (used for debug)
	 * 
	 * @param response
	 *            the response
	 * @throws IOException
	 *             Signals that an I/O exception has occurred.
	 */
	@SuppressWarnings("unused")
	private void printResponseEntity(HttpResponse response) throws IOException {
		Logger.getLogger(LogUtils.server).info("printing entity string...");

		HttpEntity entity = response.getEntity();
		String entityString = null;
		if (entity != null) {
			InputStream instream = entity.getContent();

			if (instream != null) {
				Writer writer = new StringWriter();
				char[] buffer = new char[1024];
				try {
					Reader reader = new BufferedReader(new InputStreamReader(instream));
					int n;
					while ((n = reader.read(buffer)) != -1) {
						writer.write(buffer, 0, n);
					}
				} finally {
					instream.close();
				}
				entityString = writer.toString();
			} else {
				entityString = "Empty entity string";
			}

			Logger.getLogger(LogUtils.server).info(entityString);
		}
	}

	/**
	 * Header debug (used for debug)
	 * 
	 * @param request
	 *            the request
	 */
	@SuppressWarnings("unused")
	private void headerDebug(HttpRequest request) {
		for (Header h : request.getAllHeaders()) {
			if (h.getName().equalsIgnoreCase("Cache-Control")
					&& (h.getValue().equalsIgnoreCase("no-cache") || h.getValue().equalsIgnoreCase("no-store"))) {
				System.out.println(h);
			}
		}
	}

}
